Skip to content

refactor: split /api/request into focused modules, add tests, add analytics#57

Merged
Woody4618 merged 10 commits into
solana-developers:mainfrom
hoodieshq:refactor/airdrop-request-modules
May 25, 2026
Merged

refactor: split /api/request into focused modules, add tests, add analytics#57
Woody4618 merged 10 commits into
solana-developers:mainfrom
hoodieshq:refactor/airdrop-request-modules

Conversation

@askov
Copy link
Copy Markdown
Contributor

@askov askov commented Apr 30, 2026

Warning

⚠️ Required environment changes before deploy

This PR renames one production env var and adds two new optional ones. Update Vercel env before promoting to production.

Rename (action required)

Before After Why
RPC_URL RPC_URL_DEVNET, RPC_URL_TESTNET lib/rpc.ts now resolves the RPC URL per network. The old RPC_URL is no longer read.

If you forget to set RPC_URL_DEVNET, devnet silently falls back to the public https://api.devnet.solana.com — the airdrop endpoint still works, but you lose the private RPC throughput / API key. No errors are surfaced.

Add (optional — server-side GA4 analytics)

Variable Notes
GA4_MEASUREMENT_ID e.g. G-XXXXXXXXXX. When unset, trackEvent() is a no-op.
GA4_API_SECRET Pairs with the above. Both must be set or analytics is silently disabled.

Pre-deploy checklist

  • Set RPC_URL_DEVNET (and RPC_URL_TESTNET if you want a private testnet RPC) in Vercel production env
  • Mirror the same in Vercel preview env if previews need authenticated RPC
  • (Optional) Set GA4_MEASUREMENT_ID + GA4_API_SECRET to enable analytics
  • Remove the now-unused RPC_URL to avoid confusion

Summary

Refactors the /api/request route handler from a 346-line monolith into a set of focused, individually testable modules, adds a vitest-based test suite (now wired into CI), introduces lightweight server-side analytics for airdrop outcomes, and tightens a couple of security and operational rough edges surfaced while pulling things apart.

A security-equivalence audit (old monolith vs new modular implementation) is in the Security review section below — no controls weakened.

Module split — app/api/request/

route.ts is now a thin entrypoint; the logic lives in:

  • handler.ts — orchestrates a request end-to-end
  • validation.ts — body parsing + 400 INVALID_BODY for non-object bodies (null, arrays)
  • ip.ts — client IP extraction, sanitization, and IP-allow-list parsing (fault-tolerant: invalid JSON now falls back to [] instead of breaking all traffic)
  • auth-bypass.ts — auth-token bypass with timing-safe comparison (timingSafeEqual) replacing the old ==
  • rate-limiting.ts — rate-limit checks
  • verify-captcha.ts — Cloudflare Turnstile verification
  • execute-airdrop.ts — Solana airdrop call
  • airdrop-error.ts — error → HTTP status + user-facing message mapping (NO_KEYPAIR now correctly 500 instead of 400; everything else preserved)
  • tracking.ts — analytics + backend transaction recording
  • types.ts — shared RequestContext / AirdropResponse types; faucetKeypair and authToken are non-enumerable with a toJSON returning <redacted>, so an accidental console.log(ctx) cannot leak them

Tests — vitest

  • New vitest.config.ts with __tests__-scoped include pattern: app/**/__tests__/**/*.test.ts, lib/**/__tests__/**/*.test.ts
  • 100+ tests covering each module plus the route handler
  • Wired into CI (.github/workflows/ci.yml) so npm test runs on every PR

Analytics — lib/analytics.ts

Server-side GA4 Measurement Protocol wrapper that records airdrop outcome events (airdrop_success, airdrop_failed with structured reason) from API routes. Fire-and-forget — never throws, never blocks the airdrop response. No PII: full wallet addresses are shortened to XXXX...YYYY before being sent; sanitized IP is used only as the GA4 client_id for visitor grouping; no auth tokens or keypair material are ever emitted. Disabled cleanly when env vars are unset.

Library cleanup

  • Extract lib/utils.ts + lib/constants.ts into focused lib/airdrop/ (AIRDROP_TIERS, VALID_AMOUNTS, resolveTier) and lib/rpc.ts (getConnection, per-network env-aware) modules
  • Tighten getClientIp (drop dead ?? undefined)
  • Rewrite getRpcUrl tests with vi.resetModules() + dynamic import so stubbed env is picked up (RPC_URLS is captured at import time)

Backend client fixes (lib/backend.ts)

  • Bypass callers (IP-allowlisted / auth-token) were silently dropped from the audit trail: backend's POST /api/transactions is .strict() with githubIdSchema.optional() (regex /^\d+$/), and passing "" failed the regex with a 400 swallowed by the surrounding logging try/catch. Pass ctx.githubUserId directly so JSON.stringify omits the key when undefined and the backend accepts it as missing.
  • Rename FaucetTransaction.github_usernamegithub_id to match the column the backend actually returns; rename the local getLastTransactions JS param accordingly. Wire format unchanged — the HTTP query key was already github_id.
  • Type transactionsAPI.create's github_id as string | undefined.
  • Remove dead methods: solanaBalancesAPI.getAllForAccount and githubValidationAPI target backend routes that don't exist and have no callers.

Other fixes folded into this PR

  • fix: restrict IP_ALLOW_LIST to rate-limit bypass only — preserves the old monolith's semantics: an IP-allow-listed caller still gets captcha-checked and still needs GitHub auth; the allow-list only skips rate-limit lookup.
  • fix: redact-secrets helper — the RequestContext mentioned above (non-enumerable secrets + toJSON: <redacted>), with a regression test that asserts JSON.stringify(ctx) does not contain the keypair or auth token.

Behavior changes worth flagging

Verified against the pre-refactor monolith on main:

  1. Bypass-token callers no longer require a GitHub session. Old code enforced GITHUB_LOGIN_REQUIRED = true unconditionally, so a bypass-token caller without a GitHub login was rejected. Aligns with the whole point of bypass tokens (machine/CI use) and with the backend github_id becoming optional. IP-allow-listed callers still need GitHub in both versions.
  2. Amount validation is now a strict whitelist. Old: 0 < amount ≤ maxAmountPerRequest accepted 0.1, 3, and (latent bug) negative numbers. New: only values in VALID_AMOUNTS = [0.5, 1, 2.5, 5] are accepted. UI is unaffected; any external/scripted caller posting non-whitelisted amounts will now get a 400.
  3. INVALID_BODY 400 for malformed bodies (null, arrays, non-JSON), previously a generic 500.

Everything else — Cloudflare IP determination with dev ::1 fallback, captcha skip conditions (fail-closed on Cloudflare errors), rate-limit math, validationAPI / transactionsAPI argument order and wire keys, faucet signing keypair, lamports math, priority fee, record-fail-open, error message strings — is preserved bit-for-bit.

Test plan

  • npm test — full vitest suite passes (100+ tests, all green)
  • npx tsc --noEmit — type-clean
  • Security-equivalence audit vs pre-refactor monolith
  • Manual: hit /api/request with a valid wallet + Turnstile token → airdrop succeeds, transaction recorded
  • Manual: hit /api/request from an IP-allow-listed caller → captcha still required, GitHub still required, but rate limit skipped
  • Manual: hit /api/request with a valid bypass token → captcha/GitHub/rate-limit all skipped, transaction recorded with github_id omitted
  • Manual: hit /api/request with a malformed body (null, array, non-JSON) → 400 INVALID_BODY
  • Manual: hit /api/request with amount: 0.1400 (was previously accepted)
  • Manual: trigger rate limit → 429, analytics airdrop_failed with reason: rate_limited
  • Manual: invalid Turnstile token → 400, analytics airdrop_failed with reason: captcha_failed
  • Manual on preview deploy: verify RPC_URL_DEVNET is set and devnet airdrops route through the private RPC

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

@askov is attempting to deploy a commit to the Solana Foundation Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread app/api/request/handler.ts Outdated
const canBypass =
isAuthorizedToBypass(ctx.authToken) || isAllowListedIp(ctx.ip);

if (!canBypass) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I get it right that we should allow airdrops for some tokens?
Previously, GitHub auth was required anyway, even for the token, but the current PR changes this and makes the criteria for airdrop, in case of a token, wider?

Copy link
Copy Markdown
Contributor

@rogaldh rogaldh May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is likely to be fixed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tweaked the conditions to match main as closely as possible. This bypass-by-IP-and-token feature isn't used - it's secured at the infra level with Cloudflare. I'd delete it to simplify the code, but that's out of scope. I tried to stay close to the current feature set.

Comment thread app/api/request/auth-bypass.ts
askov and others added 9 commits May 13, 2026 20:27
…lytics

* Refactor /api/request into focused modules, add test, add analytics

* Reject non-object bodies as 400, fix rpc fallback test

- buildContext now type-guards req.json() output so null/array bodies
  return INVALID_BODY (400) instead of crashing into a generic 500.
- Rewrite getRpcUrl tests to vi.resetModules() + dynamic import so the
  stubbed env is actually picked up (RPC_URLS is captured at import
  time); add cases for both defaults and overrides.
- Drop dead `?? undefined` on getClientIp's || chain.

* Omit github_id for bypass callers, align FaucetTransaction with backend

The backend's POST /api/transactions schema is .strict() with
githubIdSchema.optional() (regex /^\d+$/). Passing "" for IP/auth-token
bypass callers would fail the regex and 400 — silently, since the
recordTransaction call is wrapped in a logging try/catch — leaving
bypass-airdrops out of the audit trail.

- Pass ctx.githubUserId directly so JSON.stringify omits the key when
  undefined; backend treats it as missing and accepts.
- Type transactionsAPI.create's github_id as string | undefined.
- Rename local getLastTransactions param github_username → github_id.
- Rename FaucetTransaction.github_username → github_id to match the
  faucet.transactions column the backend actually returns.
- Add a regression test asserting recordTransaction receives undefined
  for the GitHub ID on bypass paths.

* Remove dead backend client methods

solanaBalancesAPI.getAllForAccount and githubValidationAPI both target
backend routes (GET /solana-balances/account/:account, GET
/gh-validation/:userId) that don't exist — and have no callers in the
app. Drop them and the githubValidationAPI export.
The refactor in 562925b collapsed three independent gates (GitHub auth,
captcha, rate limits) behind a single `canBypass` flag, so an
allow-listed IP skipped all three. cf-connecting-ip is set from a
request header that any client can forge if the Vercel origin is
reachable without going through Cloudflare, turning a spoofable trust
signal into a full authorization bypass.

Restore main's original semantics: IP_ALLOW_LIST only short-circuits
rate limiting; GitHub auth and captcha still run. AUTH_TOKENS_ALLOW_LIST
remains a full bypass since it's gated by a constant-time secret
compare, which is a real authentication signal.

Tests updated to assert the corrected matrix, including a regression
test that a spoofed cf-connecting-ip with no GitHub session is rejected
with github_auth_required.
@askov askov force-pushed the refactor/airdrop-request-modules branch from 2e125ff to d68a6f4 Compare May 13, 2026 13:29
- Split RPC_URL into RPC_URL_DEVNET / RPC_URL_TESTNET to match
  lib/rpc.ts after the per-network refactor.
- Document GA4_MEASUREMENT_ID and GA4_API_SECRET for the new
  server-side analytics in lib/analytics.ts; both are optional
  (trackEvent is a no-op when either is unset).
- Add the GA4 vars to types.d.ts so process.env access type-checks.
Copy link
Copy Markdown
Contributor

@rogaldh rogaldh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@vercel
Copy link
Copy Markdown

vercel Bot commented May 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
solana-devnet-faucet Ready Ready Preview, Comment May 18, 2026 11:56am

Request Review

@Woody4618 Woody4618 merged commit 8db6c0a into solana-developers:main May 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants